fix(desktop): stage and sign libnode dylib for Homebrew-installed Node#7803
fix(desktop): stage and sign libnode dylib for Homebrew-installed Node#7803formed2forge wants to merge 5 commits into
Conversation
…agent-runtime Homebrew node (v22+) is a small stub binary that dlopen()s libnode.X.dylib via @loader_path at startup. stage_local_node only copied the stub, so the binary aborted immediately with "Library not loaded: @rpath/libnode.X.dylib" when run from the Resources directory. Fix: after copying the stub, detect a libnode dependency via otool, locate the dylib in the Homebrew prefix, and copy it alongside the node binary so @loader_path can resolve it at both the validation step and app runtime. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The awk gsub left the tab from otool output before the filename, causing the path search to find nothing. Use match/substr to extract just the libnode.X.dylib token directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When node is staged from a Homebrew dynamic build, libnode.X.dylib lands in the resource bundle. codesign rejects the app bundle if it contains an unsigned dylib, causing "internal error in Code Signing subsystem". Sign any libnode.*.dylib found alongside the node binary before the final app bundle signature step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
libnode contains V8's JIT runtime. Signing it with --options runtime but no entitlements triggers a Code Signing subsystem internal error when the bundle is signed. Use the same Node.entitlements as the node binary (allow-jit + allow-unsigned-executable-memory). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- chmod u+w the staged libnode dylib after cp -f: Homebrew installs it 0444, so codesign --force would fail with EACCES without this - gitignore libnode.*.dylib in Desktop/Sources/Resources: only the `node` binary was previously ignored; the 70MB dylib was an accidental-commit hazard - remove --entitlements from libnode dylib signing: macOS reads process entitlements only from the main executable, not from loaded dylibs; sign with --options runtime only and correct the comment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR fixes a crash in
Confidence Score: 4/5Safe to merge for development workflows; the fix is correctly scoped to Homebrew-installed node and has no effect on the --universal-node release path. The signing order, entitlements handling, and gitignore entry are all correct. The open question is whether the staged dylib actually resolves at runtime — modern Homebrew node stubs use @rpath/libnode.X.dylib, and the RPATH entries in the binary determine where dyld looks. If the only matching RPATH entry is an absolute /opt/homebrew/lib path, the staged copy in the bundle is redundant during development and would still fail on a machine without Homebrew. desktop/scripts/prepare-agent-runtime.sh — the dylib candidate search and placement logic depends on an assumption about the node stub's RPATH entries that is worth verifying. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[stage_local_node called] --> B[cp node stub to NODE_RESOURCE]
B --> C[otool -L node_bin]
C --> D{libnode.X.dylib dependency found?}
D -- No, static build --> E[log version, done]
D -- Yes, Homebrew stub --> F[Search candidates:
1. node_bin_dir/../lib/libnode.X.dylib
2. node_bin_dir/libnode.X.dylib
3. brew --prefix/lib/libnode.X.dylib]
F --> G{Found?}
G -- No --> H[exit 1 with error]
G -- Yes --> I[cp dylib alongside node
chmod u+w
xattr -cr]
I --> E
subgraph run.sh signing order
J[NODE_BIN exists?] --> K[for libnode.*.dylib in bundle dir]
K --> L{file exists?}
L -- No --> M[continue]
L -- Yes --> N[codesign --options runtime
no entitlements]
N --> O[codesign node binary
with entitlements]
O --> P[codesign app bundle]
end
Reviews (1): Last reviewed commit: "fix(desktop): address code review findin..." | Re-trigger Greptile |
| for candidate in \ | ||
| "$node_bin_dir/../lib/$libnode_name" \ | ||
| "$node_bin_dir/$libnode_name" \ | ||
| "$(brew --prefix 2>/dev/null)/lib/$libnode_name"; do | ||
| if [ -f "$candidate" ]; then | ||
| libnode_src="$(realpath "$candidate")" | ||
| break | ||
| fi | ||
| done | ||
| if [ -z "$libnode_src" ]; then | ||
| echo "ERROR: node requires $libnode_name but it was not found near $node_bin or in Homebrew lib." >&2 | ||
| exit 1 | ||
| fi | ||
| cp -f "$libnode_src" "$(dirname "$NODE_RESOURCE")/$libnode_name" | ||
| chmod u+w "$(dirname "$NODE_RESOURCE")/$libnode_name" | ||
| xattr -cr "$(dirname "$NODE_RESOURCE")/$libnode_name" 2>/dev/null || true | ||
| log "Staged $libnode_name alongside node (Homebrew dynamic build)" | ||
| fi |
There was a problem hiding this comment.
@rpath vs @loader_path — staged path may not resolve at runtime
The PR description says the Homebrew stub references @loader_path/libnode.X.dylib, but a related upstream issue (nexu-io/open-design#1275) shows modern Homebrew node uses @rpath/libnode.X.dylib instead. With @rpath, dyld iterates the binary's LC_RPATH entries — which for a Homebrew node stub are typically @loader_path/../lib and the absolute /opt/homebrew/lib path.
Placing the dylib in the same directory ($(dirname "$NODE_RESOURCE")/) satisfies @loader_path/ in the RPATH only if that exact entry exists. If the RPATH has only @loader_path/../lib plus the absolute Homebrew path, the staged copy would be silently ignored and the binary would still resolve the dylib from /opt/homebrew/lib. For a fully self-contained bundle, the dylib needs to be placed to match an actual LC_RPATH entry, or the install name of the staged dylib needs to be patched with install_name_tool -add_rpath. Can you confirm that otool -l /opt/homebrew/bin/node | grep -A2 LC_RPATH shows @loader_path/ (without ../lib) as one of the entries?
| cp -f "$libnode_src" "$(dirname "$NODE_RESOURCE")/$libnode_name" | ||
| chmod u+w "$(dirname "$NODE_RESOURCE")/$libnode_name" | ||
| xattr -cr "$(dirname "$NODE_RESOURCE")/$libnode_name" 2>/dev/null || true |
There was a problem hiding this comment.
$(dirname "$NODE_RESOURCE") is evaluated three times in a row. Capturing it in a local variable makes the intent clearer and avoids repeated subshell overhead.
| cp -f "$libnode_src" "$(dirname "$NODE_RESOURCE")/$libnode_name" | |
| chmod u+w "$(dirname "$NODE_RESOURCE")/$libnode_name" | |
| xattr -cr "$(dirname "$NODE_RESOURCE")/$libnode_name" 2>/dev/null || true | |
| local node_resource_dir | |
| node_resource_dir="$(dirname "$NODE_RESOURCE")" | |
| cp -f "$libnode_src" "$node_resource_dir/$libnode_name" | |
| chmod u+w "$node_resource_dir/$libnode_name" | |
| xattr -cr "$node_resource_dir/$libnode_name" 2>/dev/null || true |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
|
fixed in #7812 |
|
Hey @formed2forge 👋 Thank you so much for taking the time to contribute to Omi! We truly appreciate you putting in the effort to submit this pull request. After careful review, we've decided not to merge this particular PR. Please don't take this personally — we genuinely try to merge as many contributions as possible, but sometimes we have to make tough calls based on:
Your contribution is still valuable to us, and we'd love to see you contribute again in the future! If you'd like feedback on how to improve this PR or want to discuss alternative approaches, please don't hesitate to reach out. Thank you for being part of the Omi community! 💜 |
Summary
Fixes #7802 —
run.shcrashes during agent runtime preparation when Node is installed via Homebrew.Homebrew's
nodebinary (v22+) is a small stub (~68KB) that dynamically loadslibnode.X.dylibat startup via@loader_path. The official nodejs.org prebuilt binaries are statically linked, so this was never an issue until a developer had only Homebrew-installed Node.Three files changed across four commits:
desktop/scripts/prepare-agent-runtime.shstage_local_node, useotool -Lto detect alibnode.X.dylibdependency, locate the dylib in the Homebrew prefix, copy it alongside the binary, andchmod u+wit (Homebrew installs it 0444, which would cause the downstreamcodesign --forceto fail with EACCES)desktop/run.shlibnode.*.dylibfound in the resource bundle before the final app bundle signature step — an unsigned dylib inside the bundle causescodesignto abort with "internal error in Code Signing subsystem"--options runtimeonly (no--entitlements) — macOS reads process entitlements exclusively from the main executable, not from loaded dylibs; the JIT permission comes from thenodebinary's own signaturedesktop/.gitignoreDesktop/Sources/Resources/libnode.*.dylib— the existing rule only covered the literalnodebinary; the 70MB dylib was an accidental-commit hazardTest plan
cd desktop && ./run.sh --yolocompletes successfully with Homebrew Node (v22+) installed[agent-runtime] Runtime validated: node=v26.0.0, agent dist and piMono files presentDesktop/Sources/Resources/libnode.*.dylibdoes not appear ingit statusafter a run--universal-nodepath (statically linked, no dylib staged) continues to work as before — new block only runs whenotool -Ldetects alibnodedependency; static nodejs.org builds have none🤖 Generated with Claude Code